Natural language processing begins with an awkward fact: text is not numbers, and every machine-learning algorithm on the other pages of this site eats numbers. So the first half of the subject is representation — how to turn a document into a vector without throwing away what makes it mean what it means. We climb a ladder of increasingly faithful representations: count the words (bag of words), discount the words that appear everywhere (tf-idf), count short sequences instead of words (n-grams), and finally learn a low-dimensional vector for each word from the company it keeps (embeddings). The second half turns representation into prediction: a language model assigns probabilities to sentences, and regular expressions — the oldest tool on the page — handle the text that must match exactly.
A vectorizer fixes a vocabulary — the list of distinct tokens seen in a corpus — and represents each document by one number per vocabulary entry. The pages' other models then apply unchanged: a document is a point, and similarity, clustering and classification are geometry.
Take the sentence: A bag-of-words is a representation of text that describes the occurrence of words within a document. Tokenize it — lowercase, strip the punctuation, split the hyphenated compound — and it becomes 17 tokens. The bag-of-words representation counts them:
{'a': 0, 'bag': 1, 'of': 2, 'words': 3, 'is': 4, 'representation': 5, 'text': 6, 'that': 7, 'describes': 8, 'the': 9, 'occurrence': 10, 'within': 11, 'document': 12}
s = [3 1 3 2 1 1 1 1 1 1 1 1 1]
Read it back: a occurs 3 times, of 3 times, words twice, every other word
once — and the entries sum to 17, the token count. What the representation keeps is frequency;
what it discards, deliberately, is order: every permutation of the sentence has the same vector.
Raw counts have a flaw across a corpus: the words that occur most (the, data in a data corpus) are usually the words that distinguish least. Tf-idf reweights each count by how rare the word is across documents. For a term $t$ in document $d$, $$\mathrm{tfidf}(t, d) = \mathrm{tf}_{t,d} \cdot \mathrm{idf}_t, \qquad \mathrm{idf}_t = \ln \frac{N}{\mathrm{df}_t},$$ where $\mathrm{tf}_{t,d}$ is the count of $t$ in $d$, $N$ is the number of documents, and $\mathrm{df}_t$ is the number of documents containing $t$. A word in every document gets $\mathrm{idf} = \ln 1 = 0$ and vanishes; a word in one document keeps its full count, scaled by $\ln N$. (Production implementations smooth both factors — scikit-learn, for instance, uses $\ln\frac{1+N}{1+\mathrm{df}_t} + 1$ and often a sublinear $1 + \ln \mathrm{tf}$ — but the reweighting idea is the formula above.)
Worked example, two documents:
The vocabulary is (data, science, uses, to, find, patterns, tests, ideas, with). Here data and science occur in both documents, so their idf is $0$; every other word occurs in one document, so its idf is $\ln 2 \approx 0.693$. Multiplying counts by idf:
d1 = [0, 0, 0.693, 0.693, 0.693, 0.693, 0, 0, 0 ]
d2 = [0, 0, 0, 0, 0, 0, 0.693, 0.693, 0.693]
# data science uses to find patterns tests ideas with
Notice what happened to data in $d_1$: it is the most frequent word in the document ($\mathrm{tf} = 2$) and tf-idf still zeroes it, because in this two-document corpus it separates nothing. Tf-idf is a statement about the corpus, not the document — add a third document without data in it and the entry comes back to life.
Bag of words and tf-idf both discard word order, and order carries meaning (dog bites man is not man bites dog). The n-gram fix is to change the token: instead of single words, count consecutive $n$-tuples of words. At $n = 1$ this is exactly bag of words. At $n = 2$ the tokens are bigrams — consecutive pairs. The sentence The n-grams typically are collected from a text or speech corpus has ten bigrams:
['The n-grams', 'n-grams typically', 'typically are',
'are collected', 'collected from', 'from a', 'a text',
'text or', 'or speech', 'speech corpus']
Now score a new sentence against that vocabulary: An apple is typically collected from a tree. Its bigrams are mostly new, but two — collected from and from a — are in the vocabulary, in positions 4 and 5, so its 2-gram representation is
s = [0 0 0 0 1 1 0 0 0 0]
The cost is combinatorial: a vocabulary of $V$ words admits $V^n$ possible n-grams, almost all unseen in any corpus, so count vectors become vast and sparse as $n$ grows. That sparsity is the pressure that pushes the subject from counting toward learning representations — the embeddings of the next section, and the language models after them.
The representations so far give every word its own coordinate axis, so a corpus of $100{,}000$ words produces $100{,}000$-dimensional document vectors — and, worse, geometry that knows nothing: the axes for cat and kitten are exactly as orthogonal as those for cat and democracy. An embedding instead assigns each word a short, dense, learned vector — typically $100$ to $300$ numbers — placed so that words used in similar contexts land near each other. Similarity of meaning becomes similarity of vectors, measured by the cosine $$\cos(v, w) = \frac{v \cdot w}{\lVert v \rVert \, \lVert w \rVert}.$$
Word2vec (Mikolov et al., 2013) learns embeddings from a prediction task nobody actually cares about: guess a word from its neighbours, or its neighbours from it. Each word $w$ carries two vectors — a target vector $v_w$ and a context vector $u_w$: $$\text{apple}\implies \begin{cases} v_{\text{apple}}\\ u_{\text{apple}} \end{cases}$$ In the skip-gram architecture, the probability of seeing context word $o$ in the window around centre word $c$ is a softmax over dot products, $$P(o \mid c) = \frac{\exp(u_o \cdot v_c)}{\sum_{w} \exp(u_w \cdot v_c)},$$ and training maximizes the log-probability of the contexts that actually occur. The mechanism is the point: to predict well, $v_c$ must have large dot products with the context vectors of the words $c$ keeps company with — so words that keep the same company are pulled to nearby vectors. Meaning is never supplied; it is squeezed out of co-occurrence.
The full softmax costs one pass over the vocabulary per training step, so in practice it is replaced by negative sampling: for each true (centre, context) pair, draw $K$ noise words and train a logistic classifier to tell them apart, $$\log \sigma(u_o \cdot v_c) + \sum_{k=1}^{K} \mathbb{E}_{w_k \sim P_n}\big[\log \sigma(-u_{w_k} \cdot v_c)\big], \qquad \sigma(x) = \frac{1}{1 + e^{-x}},$$ where the noise distribution keeps frequent words frequent but flattens them, $P_n(w) \propto \mathrm{count}(w)^{0.75}$. Each positive example pulls $v_c$ toward the true context vectors; each negative pushes it away from vectors it should not resemble:
The famous payoff is that the learned geometry encodes relations as directions: $v_{\text{king}} - v_{\text{man}} + v_{\text{woman}}$ lands nearest to $v_{\text{queen}}$ — the "royalty" direction survives subtracting the "male" direction and adding the "female" one. GloVe (Pennington et al., 2014) reaches similar geometry by factoring a global co-occurrence matrix instead of predicting locally. The explorer below is a toy version of exactly this geometry.
Eighteen words, each with a two-dimensional embedding built by hand so the relations are exact — real embeddings have hundreds of dimensions and are learned, not drawn, but every computation below is the real one. Click a word to see its nearest neighbours by cosine similarity. Pick two words to compare them, or run an analogy $a - b + c$ and watch where the arithmetic lands.
Three honest caveats. First, two dimensions are a cartoon: real embeddings need their hundreds of dimensions, and projecting them down (by PCA or t-SNE) distorts the neighbourhoods it shows. Second, cosine similarity measures relatedness in the training corpus, not truth — antonyms like hot and cold keep nearly identical company and end up close. Third, the corpus's biases are in the geometry too; the same arithmetic that finds queen also recovers the stereotypes of the text it trained on.
A language model assigns a probability to a sequence of words. By the chain rule, $$P(w_1 w_2 \cdots w_T) = \prod_{t=1}^{T} P\big(w_t \mid w_1 \cdots w_{t-1}\big),$$ so modelling text is one repeated task: predict the next word from its history. An n-gram language model truncates the history to the last $n - 1$ words (the Markov assumption) and estimates the conditional by counting, $$P(w_t \mid w_{t-n+1} \cdots w_{t-1}) \approx \frac{\mathrm{count}(w_{t-n+1} \cdots w_t)} {\mathrm{count}(w_{t-n+1} \cdots w_{t-1})}.$$ Models are compared by perplexity, the exponential of the mean negative log-probability per word — intuitively, the model's effective branching factor: a perplexity of $20$ means it is as uncertain as if choosing uniformly among $20$ words at every step.
The counting estimator collapses on unseen n-grams — most grammatical continuations never occur in any finite corpus, and a single zero probability zeroes the whole sentence — so practical n-gram models smooth: reserve a little probability mass for the unseen (add-$k$ smoothing, and more carefully Kneser–Ney). Neural language models dissolve the problem differently: words enter as embeddings, so an unseen sequence of similar words borrows probability from seen ones, and recurrent networks, then transformers, removed the fixed window entirely — attention lets the prediction at position $t$ look back at any earlier position, which is the architecture behind modern large language models (see the transformer section of the Neural Networks page). The task never changed — predict the next word — only the memory and the geometry did.
A regular expression is a pattern written in a small language of its own: literal
characters match themselves, and the operators below repeat, alternate and group them.
(The name is the mathematician's: without back-references, a pattern describes exactly a
regular language — the kind a finite-state machine can recognise — which is why a
match is fast and its cost is easy to predict.) Regular expressions are the oldest tool on
this page, and they survive because some text work must be exact rather than
probable: pulling a date out of a log line, checking that a field has the right shape,
splitting a corpus into tokens, blanking every digit in a transcript. For those jobs a
pattern is deterministic, inspectable and needs no training data. Its limits are just as
clear: a pattern sees shape, not meaning — it cannot tell a real date from
2026-99-99, which has the same shape — and it is the wrong tool for nested
structure such as HTML, which no regular language can describe.
The table below collects the common building blocks. Two details repay attention, because
both are frequent sources of wrong matches. First, the anchors ^ and
$ match the start and end of the string; they match at line breaks
only when the engine's multiline flag is set. Second, the quantifiers are greedy
by default — they take as much as they can — and a trailing ? makes one
lazy, taking as little as it can; the Examples below show the difference on the same
input.
Character Classes
Assertions
Groups and Ranges
Quantifiers
Three worked examples, each on a stated input; every output below was produced by running the pattern, in the two engines the next sections use. They share one running text:
Order 42 shipped on 2026-10-30; order 7 ships on 2026-11-02.
extract datespattern \b\d{4}-\d{2}-\d{2}\b
matches ['2026-10-30', '2026-11-02']
# \d{4} is exactly four digits; the \b anchors keep the
# pattern from starting inside a longer digit string.
# Shape only: 2026-99-99 matches too — validate after.
whole wordsinput The cat sat. The catalog lists cats, not dogs.
pattern cat -> ['cat', 'cat', 'cat'] (inside catalog, cats)
pattern \bcat\b -> ['cat'] (the standalone word only)
greedy vs lazyinput <b>bold</b> and <i>ital</i>
pattern <.*> -> ['<b>bold</b> and <i>ital</i>'] (one longest run)
pattern <.*?> -> ['<b>', '</b>', '<i>', '</i>'] (each tag)
The three examples above, live. Type a pattern — or press an example button — and see where it lands: matches are highlighted in the text and listed with their positions and capture groups. It runs in your browser, on the JavaScript engine of the next section, and trying the greedy pattern first and the lazy one second is the fastest way to feel the difference the table only states.
In JavaScript a regular expression is a built-in object, written either as a literal
between slashes (with flags after the closing slash — g for global,
i for case-insensitive) or built with the RegExp constructor,
which takes the pattern as a string and so needs each backslash doubled.
const re = /\w+\s/g; // each word together with the space after it const str = 'fee fi fo fum'; str.match(re);Outputs :
["fee ", "fi ", "fo "]
// The following two expressions are equivalent
const reLiteral = /\w+\s/g;
const reObject = new RegExp('\\w+\\s', 'g');
// The running text from Examples, same pattern
'Order 42 shipped on 2026-10-30; order 7 ships on 2026-11-02.'
.match(/\b\d{4}-\d{2}-\d{2}\b/g);
// ["2026-10-30", "2026-11-02"]
// Groups are numbered from 1; $1 in the replacement
// is whatever the first group captured.
let input = 'x^cos(y)';
input = input.replace(/\^/g, '**');
input = input.replace(/(cos|sin|tan|sqrt)/g, 'Math.$1');
console.log(input); // x**Math.cos(y)
In Python, regular expressions come from the re library. Write patterns as raw
strings (r"...") so a backslash reaches the engine instead of being read
as a Python escape first. One distinction decides many bugs: re.match
tries the pattern only at the start of the string, while re.search scans
for it anywhere. Flags such as re.MULTILINE are passed by keyword —
the third positional argument of re.split, for example, is a maximum
split count, not a flag.
import re
findalltext = "Order 42 shipped on 2026-10-30; order 7 ships on 2026-11-02." # findall returns every non-overlapping match re.findall(r"\b\d{4}-\d{2}-\d{2}\b", text)Outputs :
['2026-10-30', '2026-11-02']
sub# In the replacement, \2 is the second captured group: # (year)-(month)-(day) is rewritten month/day/year. re.sub(r"(\d{4})-(\d{2})-(\d{2})", r"\2/\3/\1", text)Outputs :
'Order 42 shipped on 10/30/2026; order 7 ships on 11/02/2026.'
match vs searchre.match(r"\d+", text) # None — position 0 is 'O', not a digit
re.search(r"\d+", text).group() # '42' — the first match anywhere